Repository navigation
feat: add GraphQL subscriptions over SSE (G3) - #1592
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Early client disconnects can bypass cleanup and permanently exhaust subscription capacity, with additional unresolved subscription-lifecycle and SSE protocol defects.
8 open issues
Release subscription slot when initial SSE write fails · New Track local-validation publishers so stop terminates them · New Preserve restartability with an empty transport set · New Reserve subscription capacity atomically before accepting requests · New Preserve SSE mode in early operation-selection errors · New Coordinate registration with synchronous cancellation and completion · New Reject nonpositive demand immediately and unblock active reads · New Coordinate cancellation with exchange startup · New
What changed in this PR
Adds GraphQL subscriptions over Server-Sent Events (SSE), extending the existing HTTP binding with graphql-sse distinct-connections support built on Restlet’s core SSE APIs.
Changes:
- Adds server streaming with heartbeats, subscription limits, back-pressure, and shutdown handling.
- Adds cold client publishers with demand handling, cancellation, and transport-error reporting.
- Adds SSE integration tests, idle-publisher fixtures, and a changelog entry.
| File | Description |
|---|---|
org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/TickPublisher.java |
Adds idle-publisher behavior for tests. |
org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlTestSchemas.java |
Adds an idle subscription field. |
org.restlet.ext.graphql/src/test/java/org/restlet/ext/graphql/GraphqlSseTestCase.java |
Tests SSE protocol, limits, demand, and lifecycle behavior. |
org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/internal/GraphqlEventStream.java |
Writes subscription results as bounded SSE streams. |
org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlTransport.java |
Defines the SSE subscription transport. |
org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlSsePublisher.java |
Implements client-side SSE subscriptions. |
org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlServerAdapter.java |
Integrates SSE negotiation, configuration, and stream lifecycle. |
org.restlet.ext.graphql/src/main/java/org/restlet/ext/graphql/GraphqlClientAdapter.java |
Exposes subscription APIs and tracks open subscriptions. |
changes.md |
Documents GraphQL SSE support and default limits. |
Review effort: Balanced
| private static Publisher<ExecutionResult> single(ExecutionResult result) { | ||
| return subscriber -> | ||
| subscriber.onSubscribe( | ||
| new Subscription() { |
There was a problem hiding this comment.
Declining: these publishers only replay a validation result computed locally before stop(), with no request or connection behind them, so there is nothing to cancel and the result stays correct. Tracking them would only turn a valid local answer into a stop error.
| if (subscription && activeSubscriptions.get() >= maxSubscriptions) { | ||
| writeEntity(response, responseType, failure(error("Too many active subscriptions"))); | ||
| response.setStatus(Status.SERVER_ERROR_SERVICE_UNAVAILABLE); |
There was a problem hiding this comment.
Declining for now: the limit stays enforced atomically in GraphqlEventStream.write(), and the check in handle() only gives the common case a 503. A real reservation would have to be released when the connector never writes the entity (for example when it drops event streams over its own limit), and Restlet has no reliable hook for that, so a leaked reservation would be worse than the rare SSE error that a lost race gets.
6518f5f to
e86b9df
Compare
e86b9df to
7ddc19d
Compare
graphql-sse in distinct connections mode, on core SSE: - Server: GraphqlTransport.SSE enables event streams for requests accepting text/event-stream: subscriptions (also over GET when it is enabled), and queries and mutations as one next event. Results are requested one at a time and written as next events, then complete. Request errors are 400 with a JSON body. An initial comment sends the response head at once; heartbeats (15 s by default) keep idle streams open and detect disconnected clients. A failed write cancels the subscription. - Limits: 64 active subscriptions per adapter (503 beyond), 1 MiB of queued bytes per subscription (the stream ends with an error and the subscription is cancelled). Stopping the adapter ends its open streams so that the server connector can shut down gracefully. - Client: subscribe() returns a cold Publisher<ExecutionResult>; each subscriber sends its own request on its first demand, from a virtual thread that emits all the signals. Demand is honored, cancel() aborts the request, events are bounded by maxResponseBytes, request errors are published as a single result, and a stream ending without a complete event is a GraphqlTransportException. stop() fails the open subscriptions. Refs #1585 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
On the server, release the subscription slot when the first write fails, answer document errors of event stream requests with 400 and keep adapters without subscription transports restartable. On the client, signal non-positive demand without sending or waiting for a request, unregister subscriptions cancelled from onSubscribe() and abort requests cancelled before their exchange started. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7ddc19d to
08f497f
Compare
|





Closes #1585. Part of #1581.
Summary
Subscriptions with
graphql-ssein distinct connections mode, on core SSE (EventStreamRepresentation,EventStreamReader). WebSocket comes in G4.Server
text/event-stream(named explicitly; wildcards don't select it) gets an event stream:nextevents carrying results, thencomplete. Queries and mutations are onenext; subscriptions are also accepted overGETwhenGETis enabled. Request errors are400with a JSON body, as ingraphql-sse.503beyond), 1 MiB of queued bytes per subscription. The queue only grows when a publisher ignores demand or a result is huge; beyond the limit the stream ends with an error and the subscription is cancelled. The connector'sserver.maxEventStreamsstill applies.stop()ends the adapter's open streams (error +complete), so that the server connector shuts down gracefully instead of waiting for idle subscriptions.Client
subscribe(String|Document, operationName, variables)returns a coldPublisher<ExecutionResult>. Each subscriber sends its ownPOSTon its first demand, from a virtual thread that emits all signals (no concurrentonNext).cancel()aborts the request (Request.abort());request(n <= 0)signalsIllegalArgumentException.400, or local validation withsetSchema) are published as a single result. Non-GraphQL responses, events abovemaxResponseBytesand streams ending withoutcompleteare aGraphqlTransportException.stop()fails open subscriptions with aGraphqlTransportException; the borrowed client keeps running.Core findings
EventStreamReaderhas no line or event size limit (BufferedReader.readLine). The client wraps the stream with a per-event byte budget (one 8 KiB read-ahead margin); a bounded reader in core would be better.EventStreamRepresentationdoes not commit the response head until the first event or heartbeat.Validation
./mvnw -B verify -P default,test -pl org.restlet.ext.graphql: 47 tests, 0 failures; 93.8% instruction coverage (gate 80%); Spotless, SpotBugs, Javadoc,-Werrorand dependency convergence pass.New
GraphqlSseTestCase(16 tests): wire format ofnext/complete; queries over SSE; JSON request errors;GETsubscriptions andGETmutations rejected; SSE off unless enabled; client disconnect cancels a busy subscription; heartbeats detect a disconnected idle client; subscription cap; queued bytes limit; client demand (no result without demand), completion, cancellation aborting the server-side subscription,stop(), request errors as results (remote and local validation), transport errors (HTML502, truncated stream, oversized event), invalid demand; configuration validation.🤖 Generated with Claude Code